Skip to content

🐛 [Shopify] Defer plugin init until a checkout page view, fixing double session IDs - #4981

Merged
lierniel merged 13 commits into
mainfrom
stephan.koshcheev/RUM-18173/shopify-fix-double-session-ids
Sep 2, 2026
Merged

🐛 [Shopify] Defer plugin init until a checkout page view, fixing double session IDs#4981
lierniel merged 13 commits into
mainfrom
stephan.koshcheev/RUM-18173/shopify-fix-double-session-ids

Conversation

@lierniel

@lierniel lierniel commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Motivation

Shopify Custom Pixel sandboxes were creating two RUM session IDs on the same page. The storefront's
Theme Liquid snippet already runs a DD_RUM instance for every page, while the Custom Pixel's
shopifyPlugin unconditionally ran its own init() side effects (patching sandboxed iframe APIs,
wiring bindings, forcing trackViewsManually, etc.) as soon as onInit fired — with no way to know
yet whether the page was actually a checkout page. See RFC: Preventing two SDK instances from
running at the same time

(RUM-18173).

Changes

  • Extended the RumPlugin.onInit contract (packages/browser-rum-core/src/domain/plugins.ts,
    preStartRum.ts) so onInit may return false to abort SDK init, or a Promise<false | void> to
    defer it. callPluginsMethod/runOnInitPlugins now run plugins' onInit in order, staying
    synchronous until a plugin returns a thenable, and time out a pending onInit after 3s (surfacing
    an error rather than hanging init forever).
  • shopifyPlugin.onInit now returns a Promise that waits for the sandbox's first page_viewed
    event and only proceeds (patches iframe APIs, wires bindings, forces sandbox-specific config) once
    that event's URL matches a checkout path — so a Custom Pixel loaded on a non-checkout page no longer
    spins up a second RUM instance. initShopifyBindings's clicked/ui_extension_errored handlers are
    now gated the same way, via the shared isCheckoutPage predicate.
  • Replaced the older makeShopifyRumPublicApi() init()-wrapping approach with the plugin-based
    shopifyPlugin, now exposed as DD_RUM.shopifyPlugin(...) (see updated
    packages/browser-rum-shopify/README.md).

Test instructions

  • yarn test:unit --spec packages/browser-rum-core/src/domain/plugins.spec.ts --spec packages/browser-rum-core/src/boot/preStartRum.spec.ts --spec packages/browser-core/src/tools/thenable.spec.ts --spec "packages/browser-rum-shopify/**/*.spec.ts"

Checklist

  • Tested locally
  • Tested on staging
  • Added unit tests for this change.
  • Added e2e/integration tests for this change.
  • Updated documentation and/or relevant AGENTS.md file

@datadog-datadog-us1-prod

datadog-datadog-us1-prod Bot commented Aug 25, 2026

Copy link
Copy Markdown

Tests

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

🎯 Code Coverage (details)
Patch Coverage: 74.63%
Overall Coverage: 77.09% (+0.13%)

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 9cfaccb | Docs | View more details | Give us feedback!

@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Aug 26, 2026

Copy link
Copy Markdown

Bundles Sizes Evolution

📦 Bundle Name Base Size Local Size 𝚫 𝚫% Status
Rum 181.46 KiB 181.69 KiB +242 B +0.13%
Rum Profiler 8.43 KiB 8.43 KiB 0 B 0.00%
Rum Recorder 22.31 KiB 22.31 KiB 0 B 0.00%
Logs 57.52 KiB 57.52 KiB 0 B 0.00%
Rum Salesforce N/A 139.71 KiB N/A N/A N/A
Rum Slim 139.47 KiB 139.71 KiB +239 B +0.17%
Worker 22.96 KiB 22.96 KiB 0 B 0.00%
Rum Shopify N/A 203.06 KiB N/A N/A N/A
Rum-shopify Profiler N/A 8.43 KiB N/A N/A N/A
Rum-shopify Recorder N/A 3.72 KiB N/A N/A N/A

@lierniel
lierniel force-pushed the stephan.koshcheev/RUM-18173/shopify-fix-double-session-ids branch from 30045a3 to 4d662d6 Compare August 26, 2026 13:28

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 suggestion: If we want to decouple the logic for the onInit and onStart hooks, I would create two separate functions. We could keep it relatively simple:

/**
 * Calls each plugin's `onInit`, and returns whether the initialization should go on: `false` if any
 * plugin aborts it. Stays synchronous as long as no plugin returns a thenable.
 */
export function callPluginsOnInit(
  plugins: RumPlugin[] | undefined,
  parameter: { initConfiguration: RumInitConfiguration; publicApi: RumPublicApi }
): boolean | Promise<boolean> {
  const results = (plugins ?? []).map((plugin) => plugin.onInit?.(parameter))

  if (results.some(isThenable)) {
    return Promise.all(results.map((result) => Promise.resolve(result))).then(
      (resolvedResults) => !resolvedResults.includes(false)
    )
  }
  return !results.includes(false)
}

export function callPluginsOnRumStart(plugins: RumPlugin[] | undefined, options: OnRumStartOptions): void {
  for (const plugin of plugins ?? []) {
    plugin.onRumStart?.(options)
  }
}

Comment thread packages/browser-rum-shopify/src/domain/shopifyPlugin.ts Outdated
@lierniel
lierniel marked this pull request as ready for review August 26, 2026 18:30
@lierniel
lierniel requested a review from a team as a code owner August 26, 2026 18:30
@lierniel
lierniel requested a review from amortemousque August 28, 2026 13:03
Comment thread packages/browser-rum-angular/src/domain/angularPlugin.spec.ts Outdated
Comment thread packages/browser-rum-core/src/boot/preStartRum.ts
Comment thread packages/browser-rum-core/src/boot/rumPublicApi.ts Outdated
Comment on lines +65 to +69
waitForThenable(Promise.resolve(result), DEFAULT_ON_INIT_TIMEOUT).catch((reason) => {
if (isTimeoutError(reason)) {
throw new Error(`Plugin ${plugins[index].name} onInit() timed out after ${DEFAULT_ON_INIT_TIMEOUT}ms`)
}
throw reason

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: keep things simple, don't handle timeouts.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean just ignore them completely?
My motivation to handle it is to show some meaningful error message to the plugins consumers - so they will know which plugin has failed to init and why instead of generic Timeout error message.
Also I wound't say it adds a lot of complexity to ours code - just another .catch block, but let me know if you disagree

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I would ignore it completely. If you really want a timeout, move it to the salesforce plugin.

The reason I am a bit reluctant is that is the main bundle size impact, with 0 benefit for the vast majority of usages.

Comment thread packages/browser-rum-core/src/domain/plugins.ts Outdated
Comment on lines +35 to +37
if (isBindingsInstalled) {
return
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you unsubscribe instead? This isBindingInstalled makes things more complex than necessary

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, there is no option to unsubscribe from shopify events unfortunately :(
Here is official API reference for analytics.subscribe method and it's returning a Promise<undefined>, not an unsubscribe function or anything I can use later to detach the listener: https://shopify.dev/docs/api/web-pixels-api/standard-api/analytics

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok! Then nitpick: I would introduce a function like waitFirstPageViewedEvent(analytics) to isolate the subscription logic. You could even have an async onInit function:

async onInit({ initConfiguration, publicApi }) {
      const analytics = configuration.shopifyAnalytics
      if (!analytics) {
        return false
      }
      const event = await waitFirstPageViewedEvent(analytics)
      if (!isCheckoutPage(event)) {
        return false
      }
      ...
}

Comment thread packages/browser-rum-core/src/domain/plugins.ts Outdated
Comment thread packages/browser-core/src/tools/thenable.ts
Comment thread packages/browser-rum-vue/test/initializeVuePlugin.ts
Comment on lines 67 to 89
@@ -90,6 +90,8 @@ describe('initShopifyBindings', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this is not related exactly to this PR but while reviewing it I saw that maybe here we could use a loop like we do in other tests. Makes it more readable IMO.

    it('starts a view on /checkout, /checkouts/*, and locale-prefixed checkout paths', () => {
      const urls = [
        'https://shop.example/checkout',
        'https://shop.example/checkouts/abc123',
        'https://shop.example/en-us/checkout',
      ]

      for (const url of urls) {
        expect(emitPageViewed(url)).toHaveBeenCalledTimes(1)
      }
    })

    it('does not start a view on storefront, /orders/*, Customer Account pages, or an undefined url', () => {
      const urls = [
        'https://shop.example/products/foo',
        'https://shop.example/orders/abc123',
        'https://shop.example/account/orders',
        undefined,
      ]

      for (const url of urls) {
        expect(emitPageViewed(url)).not.toHaveBeenCalled()
      }
    })
  })

Comment on lines +136 to +137
// @ts-expect-error - shopifyAnalytics is required
const result = shopifyPlugin({}).onInit!({ initConfiguration, publicApi })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think of:

const result = shopifyPlugin({ shopifyAnalytics: undefined as unknown as ShopifyAnalyticsApi }).onInit!({ initConfiguration, publicApi })

That way we avoid the eslint ignore.

Comment on lines +5 to +25
function createFakeAnalytics() {
const subscribers = new Map<string, (event: ShopifyPixelEvent) => void>()
const analytics: ShopifyAnalyticsApi = {
subscribe: jasmine.createSpy('subscribe').and.callFake((eventName: string, callback) => {
subscribers.set(eventName, callback)
}),
}
return {
analytics,
emit: (eventName: string, event: ShopifyPixelEvent) => subscribers.get(eventName)?.(event),
}
}

function pageViewedEvent(url: string | undefined): ShopifyPixelEvent {
return {
name: 'page_viewed',
id: '1',
timestamp: '2026-07-06T00:00:00Z',
context: { document: { location: { href: url } } },
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createFakeAnalytics and pageViewedEvent are defined in the 3 spec files under domain. What about moving it into a src/test/mockShopifyAnalytics file where we export them?

const results = plugins.map((plugin) => plugin.onInit?.(parameter))

if (results.some(isThenable)) {
return Promise.all(results.map((result) => Promise.resolve(result))).then((results) => !results.includes(false))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick:

Suggested change
return Promise.all(results.map((result) => Promise.resolve(result))).then((results) => !results.includes(false))
return Promise.all(results).then((results) => !results.includes(false))

No need to wrap into promises

@lierniel
lierniel merged commit 553c186 into main Sep 2, 2026
31 checks passed
@lierniel
lierniel deleted the stephan.koshcheev/RUM-18173/shopify-fix-double-session-ids branch September 2, 2026 16:21
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 2, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants